在不断构建、迭代和改进产品时,从用户那里收集某种形式的分析非常重要。了解你的用户如何在现实生活中使用你的应用程序,有时真的会让人惊讶,并把它的发展方向,或作为新功能的灵感。
虽然肯定有一些方法让它变得过分,让人毛骨悚然,还有很多方法可以实现系统,这些系统可以告诉你产品的实际使用情况,同时尊重用户的隐私、数据使用和整体体验。
然而,实现一个在代码中易于使用的可靠分析系统却非常困难。本周,让我们看看这样一个系统是如何架构和实现的,它是基于我最喜欢的Swift特性之一——枚举!
The requirements
在开始构建任何形式的系统之前,根据您希望它如何工作和您需要它做什么,写下一个需求列表总是一个好主意。
对于我们的分析系统,我们将设定4个目标:
它需要容易地记录事件从任何视图控制器。您应该只需要一行代码就可以记录一些内容。
该系统应该支持任何底层系统,以便将事件实际发送到某种形式的后端。
系统应该是高度可测试的,并且容易验证。
当调用站点需要更新时,应该很容易添加、删除和修改事件,并获得编译时错误。
Common approaches
在我们进入Xcode并开始编写代码之前,还有一件事——让我们看看一些在应用程序中实现分析的常见方法,看看我们能从这些方法中学到什么。
Singletons
到目前为止,实现分析系统最常见的方法是使用基于单例的方法。 就像我们在“Swift中避免单例”中看到的那样,使用单例是一种完全有效的方法(而且非常方便),但它也可能很快让我们的应用更难测试和维护。
在大多数情况下,日志分析事件应该被认为是控制器层的一部分(如果你没有使用MVC,则可以认为是等价的逻辑层),当使用单例时,分析代码很容易泄漏到模型或视图层。
Strings
在应用中设置分析的另一种常见方法是使用字符串作为标识符。这是非常灵活的,可以让您快速更改所使用的标识符。然而,这也是分析代码中常见错误和bug的来源。在一个地方改变一个字符串,却忘记在另一个地方更新它,这太容易了。随着时间的推移,这通常会使这类系统更难维护,并通常会导致大量噪音和无效事件——这使得数据分析更加困难。
Third party SDKs
最后,实现分析的一个常见解决方案是使用第三方服务或SDK。从很多方面来看,这都是一个很好的解决方案,可以让你更快更容易地执行分析,但要注意你在应用中使用的是哪种sdk-确保调查他们从你的用户那里收集了什么类型的数据,以尊重他们的隐私。
所以我并不反对使用第三方SDK,恰恰相反。然而,当将这样的SDK添加到应用程序中时,我建议总是在代码和自己的代码之间添加一层。这样做将使测试变得更容易,并且在将来切换任何此类解决方案时更加灵活。
Setting up the architecture
好了,我们已经把要求写下来了,我们也做了研究——让我们开始工作吧!⚒
我们设置分析系统的方法是从一个AnalyticsManager开始。 这个类将作为记录事件的顶级API,这个类的实例将被注入到任何想要使用我们系统的视图控制器中。
但是我们的AnalyticsManager实际上不会做任何日志记录。 相反,它将使用一个AnalyticsEngine将事件发送到后端。AnalyticsEngine将是一个我们可以有多个实现的协议(例如一个用于测试,一个用于分段,一个用于生产)。这也将使我们更容易切换任何第三方SDK,我们可能会在未来使用。
最后,我们将有一个名为AnalyticsEvent的枚举,它将包含分析系统支持的所有事件。我们将使用这个设置而不是普通字符串,这都是为了在编译时保证我们的事件是正确的,并且在将来使重构和其他更改变得更加容易。
Let's get coding
让我们从头开始,首先实现AnalyticsEvent。我们将使用没有原始值的枚举,并实现一些我们最初想要支持的事件:
enum AnalyticsEvent {
case loginScreenViewed
case loginAttempted
case loginFailed(reason: LoginFailureReason)
case loginSucceeded
case messageListViewed
case messageSelected(index: Int)
case messageDeleted(index: Int, read: Bool)
}
关于上述代码的两点注意事项:
对于某些事件,我们添加了额外的元数据,例如LoginFailureReason用于loginFailed事件。这将让我们能够更轻松地分析分析数据,回答“为什么这么多用户无法登录我们的应用?”等问题。
当包含元数据时,我们使用匿名信息,比如UI中消息的索引,或者一个简单的Bool来指示是否读取了它。理想情况下,我们不应该包含诸如消息ID或用户ID这样的ID,因为记录这些数据会很快导致用户隐私受损。
Start your engine
接下来,让我们实现AnalyticsEngine协议
protocol AnalyticsEngine: class {
func sendAnalyticsEvent(named name: String, metadata: [String : String])
}
非常简单,但是当您看到上面的代码时,可能会感到有些惊讶。我刚才不是说我们不应该用自由形式字符串作为标识符吗? 我们新实现的AnalyticsEvent枚举呢? 🤔
虽然我们希望所有顶级调用都以类型安全的方式使用AnalyticsEvent,但我们不希望底层引擎必须知道该类型。这将给我们在未来重构事物时更多的灵活性,我们可以保证一个统一的序列化过程,而不是把它留给每个引擎。
Engine implementations
这种设置的美妙之处在于它支持AnalyticsEngine协议的多种实现。例如,我们可以从一个简单的基于CloudKit的示例开始:
class CloudKitAnalyticsEngine: AnalyticsEngine {
private let database: CKDatabase
init(database: CKDatabase = CKContainer.default().publicCloudDatabase) {
self.database = database
}
func sendAnalyticsEvent(named name: String, metadata: [String : String]) {
let record = CKRecord(recordType: "AnalyticsEvent.\(name)")
for (key, value) in metadata {
record[key] = value as NSString
}
database.save(record) { _, _ in
// We treat this as a fire-and-forget type operation
}
}
}
或者我们可以使用更高级的解决方案,比如将数据发送到我们自己的后端数据库,或者使用第三方sdk,如Mixpanel或Logmatic。我们也可以很容易地实现一个模拟的引擎进行测试,更多的内容将在下周😉。
Serialization
在我们继续执行并通过实现AnalyticsManager来完成事情之前,让我们看看如何序列化一个AnalyticsEvent值,以便为AnalyticsEngine使用它做好准备。
有两个部分,事件名称和它的元数据。在大多数情况下,这个名称非常容易自动生成,因为我们可以使用标准库的String(description:) API让Swift生成一个表示所有不带关联值的情况的字符串。 对于具有关联值的情况,我们将手动返回一个名称。
extension AnalyticsEvent {
var name: String {
switch self {
case .loginScreenViewed, .loginAttempted,
.loginSucceeded, .messageListViewed:
return String(describing: self)
case .loginFailed:
return "loginFailed"
case .messageSelected:
return "messageSelected"
case .messageDeleted:
return "messageDeleted"
}
}
}
对于元数据,我们要么必须手动将给定的enum值转换为字典,要么使用自动编码器,如Wrap。下面是一个简单的手动实现:
extension AnalyticsEvent {
var metadata: [String : String] {
switch self {
case .loginScreenViewed, .loginAttempted,
.loginSucceeded, .messageListViewed:
return [:]
case .loginFailed(let reason):
return ["reason" : String(describing: reason)]
case .messageSelected(let index):
return ["index" : "\(index)"]
case .messageDeleted(let index, let read):
return ["index" : "\(index)", "read": "\(read)"]
}
}
}
The API
我们终于准备好把所有东西放在一起并实现AnalyticsManager了。管理器将在其初始化器中接受一个符合AnalyticsEngine的对象,并提供一个API来让我们记录一个给定的事件,像这样:
class AnalyticsManager {
private let engine: AnalyticsEngine
init(engine: AnalyticsEngine) {
self.engine = engine
}
func log(_ event: AnalyticsEvent) {
engine.sendAnalyticsEvent(named: event.name, metadata: event.metadata)
}
}
很简单,但这就是我们真正需要的!🎉
Usage
对任何系统的真正测试是如何使用它的API,以及调用站点是什么样子的。让我们在MessageListViewController中实现我们的分析系统:
class MessageListViewController: UIViewController {
private let messages: MessageCollection
private let analytics: AnalyticsManager
init(messages: MessageCollection, analytics: AnalyticsManager) {
self.messages = messages
self.analytics = analytics
super.init(nibName: nil, bundle: nil)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
analytics.log(.messageListViewed)
}
private func deleteMessage(at index: Int) {
let message = messages.delete(at: index)
analytics.log(.messageDeleted(index: index, read: message.read))
}
}
正如你在上面看到的,我们使用了经典的基于初始化器的依赖注入,将我们的AnalyticsManager传递到我们的视图控制器,作为它的设置过程的一部分。在这里,你也可以使用基于属性的依赖注入(例如,如果你在使用故事板),或者“使用Swift中的工厂的依赖注入”中的基于工厂的方法。
How did we do?
那么我们的最终执行与我们的4个目标之间的差距是什么呢?
你现在可以从任何视图控制器轻松地记录事件。只需注入一个AnalyticsManager,并使用一行代码来记录任何事件。
使用AnalyticsEngine的各种实现,我们可以支持多个后端或第三方sdk。
因为AnalyticsEngine是一种协议,所以很容易在测试中模拟它。
因为AnalyticsEvent是一个类型安全的枚举,它为我们增加了额外的安全级别,我们可以利用编译器来确保我们的设置是正确的。
Conclusion
使用三个不同的部分,一个管理器,一个引擎和一个事件枚举,我们现在能够轻松地编写可预测和灵活的分析代码,大量的编译时检查。
这种方法不仅适用于分析,而且管理器+引擎组合也是抽象硬件传感器、位置服务等内容的好方法。它的优点是可以将逻辑和代码从与底层依赖项的交互中分离出来。这极大地增加了代码经受时间测试的机会,并且在底层依赖项发生变化时,不必完全重写代码。